/*
 * Copyright (c) 2011, the Dart project authors.
 * 
 * Licensed under the Eclipse Public License v1.0 (the "License"); you may not use this file except
 * in compliance with the License. You may obtain a copy of the License at
 * 
 * http://www.eclipse.org/legal/epl-v10.html
 * 
 * Unless required by applicable law or agreed to in writing, software distributed under the License
 * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
 * or implied. See the License for the specific language governing permissions and limitations under
 * the License.
 */
package com.google.dart.tools.ui.internal.text.functions;

import com.google.dart.tools.ui.internal.text.functions.TypingRun.ChangeType;

import org.eclipse.core.runtime.Assert;
import org.eclipse.jface.text.DocumentEvent;
import org.eclipse.jface.text.ITextListener;
import org.eclipse.jface.text.ITextViewer;
import org.eclipse.jface.text.TextEvent;
import org.eclipse.swt.SWT;
import org.eclipse.swt.custom.StyledText;
import org.eclipse.swt.events.FocusEvent;
import org.eclipse.swt.events.FocusListener;
import org.eclipse.swt.events.KeyEvent;
import org.eclipse.swt.events.KeyListener;
import org.eclipse.swt.events.MouseEvent;
import org.eclipse.swt.events.MouseListener;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Set;

/**
 * When connected to a text viewer, a <code>TypingRunDetector</code> observes <code>TypingRun</code>
 * events. A typing run is a sequence of similar text modifications, such as inserting or deleting
 * single characters.
 * <p>
 * Listeners are informed about the start and end of a <code>TypingRun</code>.
 * </p>
 */
public class TypingRunDetector {
  /*
   * Implementation note: This class is independent of JDT and may be pulled up to jface.text if
   * needed.
   */

  /**
   * Instances of this class abstract a text modification into a simple description. Typing runs
   * consists of a sequence of one or more modifying changes of the same type. Every change records
   * the type of change described by a text modification, and an offset it can be followed by
   * another change of the same run.
   */
  private static final class Change {
    private ChangeType fType;
    private int fNextOffset;

    /**
     * Creates a new change of type <code>type</code>.
     * 
     * @param type the <code>ChangeType</code> of the new change
     * @param nextOffset the offset of the next change in a typing run
     */
    public Change(ChangeType type, int nextOffset) {
      fType = type;
      fNextOffset = nextOffset;
    }

    /**
     * Returns <code>true</code> if the receiver can extend the typing run the last change of which
     * is described by <code>change</code>.
     * 
     * @param change the last change in a typing run
     * @return <code>true</code> if the receiver is a valid extension to <code>change</code>,
     *         <code>false</code> otherwise
     */
    public boolean canFollow(Change change) {
      if (fType == TypingRun.NO_CHANGE) {
        return true;
      }
      if (fType.equals(TypingRun.UNKNOWN)) {
        return false;
      }
      if (fType.equals(change.fType)) {
        if (fType == TypingRun.DELETE) {
          return fNextOffset == change.fNextOffset - 1;
        } else if (fType == TypingRun.INSERT) {
          return fNextOffset == change.fNextOffset + 1;
        } else if (fType == TypingRun.OVERTYPE) {
          return fNextOffset == change.fNextOffset + 1;
        } else if (fType == TypingRun.SELECTION) {
          return true;
        }
      }
      return false;
    }

    /**
     * Returns the change type of this change.
     * 
     * @return the change type of this change
     */
    public ChangeType getType() {
      return fType;
    }

    /**
     * Returns <code>true</code> if the receiver describes a text modification, <code>false</code>
     * if it describes a focus / selection change.
     * 
     * @return <code>true</code> if the receiver is a text modification
     */
    public boolean isModification() {
      return fType.isModification();
    }

    /*
     * @see java.lang.Object#toString()
     */
    @Override
    public String toString() {
      return fType.toString() + "@" + fNextOffset; //$NON-NLS-1$
    }
  }

  /**
   * Observes non-modifying events that will end a run, such as clicking into the editor, moving the
   * caret, and the editor losing focus. These events can never start a run, therefore this listener
   * is only registered if there is an ongoing run.
   */
  private class SelectionListener implements MouseListener, KeyListener, FocusListener {

    /*
     * @see org.eclipse.swt.events.FocusListener#focusGained(org.eclipse.swt.events .FocusEvent)
     */
    @Override
    public void focusGained(FocusEvent e) {
      handleSelectionChanged();
    }

    /*
     * @see org.eclipse.swt.events.FocusListener#focusLost(org.eclipse.swt.events .FocusEvent)
     */
    @Override
    public void focusLost(FocusEvent e) {
    }

    /*
     * On cursor keys, the current editing command is closed
     * 
     * @see KeyListener#keyPressed
     */
    @Override
    public void keyPressed(KeyEvent e) {
      switch (e.keyCode) {
        case SWT.ARROW_UP:
        case SWT.ARROW_DOWN:
        case SWT.ARROW_LEFT:
        case SWT.ARROW_RIGHT:
        case SWT.END:
        case SWT.HOME:
        case SWT.PAGE_DOWN:
        case SWT.PAGE_UP:
          handleSelectionChanged();
          break;
      }
    }

    /*
     * @see KeyListener#keyPressed
     */
    @Override
    public void keyReleased(KeyEvent e) {
    }

    /*
     * @see MouseListener#mouseDoubleClick
     */
    @Override
    public void mouseDoubleClick(MouseEvent e) {
    }

    /*
     * If the right mouse button is pressed, the current editing command is closed
     * 
     * @see MouseListener#mouseDown
     */
    @Override
    public void mouseDown(MouseEvent e) {
      if (e.button == 1) {
        handleSelectionChanged();
      }
    }

    /*
     * @see MouseListener#mouseUp
     */
    @Override
    public void mouseUp(MouseEvent e) {
    }
  }

  /**
   * Observes any events that modify the content of the document displayed in the editor. Since text
   * events may start a new run, this listener is always registered if the detector is connected.
   */
  private class TextListener implements ITextListener {

    /*
     * @see org.eclipse.jface.text.ITextListener#textChanged(org.eclipse.jface.text .TextEvent)
     */
    @Override
    public void textChanged(TextEvent event) {
      handleTextChanged(event);
    }
  }

  /** Debug flag. */
  private static final boolean DEBUG = false;

  /** The listeners. */
  private final Set<ITypingRunListener> fListeners = new HashSet<ITypingRunListener>();
  /**
   * The viewer we work upon. Set to <code>null</code> in <code>uninstall</code> .
   */
  private ITextViewer fViewer;
  /** The text event listener. */
  private final TextListener fTextListener = new TextListener();
  /**
   * The selection listener. Set to <code>null</code> when no run is active.
   */
  private SelectionListener fSelectionListener;

  /* state variables */

  /** The most recently observed change. Never <code>null</code>. */
  private Change fLastChange;
  /** The current run, or <code>null</code> if there is none. */
  private TypingRun fRun;

  /**
   * Adds a listener for <code>TypingRun</code> events. Repeatedly adding the same listener instance
   * has no effect. Listeners may be added even if the receiver is neither connected nor installed.
   * 
   * @param listener the listener add
   */
  public void addTypingRunListener(ITypingRunListener listener) {
    Assert.isLegal(listener != null);
    fListeners.add(listener);
    if (fListeners.size() == 1) {
      connect();
    }
  }

  /**
   * Installs the receiver with a text viewer.
   * 
   * @param viewer the viewer to install on
   */
  public void install(ITextViewer viewer) {
    Assert.isLegal(viewer != null);
    fViewer = viewer;
    connect();
  }

  /**
   * Removes the listener from this manager. If <code>listener</code> is not registered with the
   * receiver, nothing happens.
   * 
   * @param listener the listener to remove, or <code>null</code>
   */
  public void removeTypingRunListener(ITypingRunListener listener) {
    fListeners.remove(listener);
    if (fListeners.size() == 0) {
      disconnect();
    }
  }

  /**
   * Uninstalls the receiver and removes all listeners. <code>install()</code> must be called for
   * events to be generated.
   */
  public void uninstall() {
    if (fViewer != null) {
      fListeners.clear();
      disconnect();
      fViewer = null;
    }
  }

  /**
   * Handles an incoming selection event.
   */
  void handleSelectionChanged() {
    handleChange(new Change(TypingRun.SELECTION, -1));
  }

  /**
   * Handles an incoming text event.
   * 
   * @param event the text event that describes the text modification
   */
  void handleTextChanged(TextEvent event) {
    Change type = computeChange(event);
    handleChange(type);
  }

  /**
   * Computes the change abstraction given a text event.
   * 
   * @param event the text event to analyze
   * @return a change object describing the event
   */
  private Change computeChange(TextEvent event) {
    DocumentEvent e = event.getDocumentEvent();
    if (e == null) {
      return new Change(TypingRun.NO_CHANGE, -1);
    }

    int start = e.getOffset();
    int end = e.getOffset() + e.getLength();
    String newText = e.getText();
    if (newText == null) {
      newText = new String();
    }

    if (start == end) {
      // no replace / delete / overwrite
      if (newText.length() == 1) {
        return new Change(TypingRun.INSERT, end + 1);
      }
    } else if (start == end - 1) {
      if (newText.length() == 1) {
        return new Change(TypingRun.OVERTYPE, end);
      }
      if (newText.length() == 0) {
        return new Change(TypingRun.DELETE, start);
      }
    }

    return new Change(TypingRun.UNKNOWN, -1);
  }

  /**
   * Initializes the state variables and registers any permanent listeners.
   */
  private void connect() {
    if (fViewer != null) {
      fLastChange = new Change(TypingRun.UNKNOWN, -1);
      fRun = null;
      fSelectionListener = null;
      fViewer.addTextListener(fTextListener);
    }
  }

  /**
   * Disconnects any registered listeners.
   */
  private void disconnect() {
    fViewer.removeTextListener(fTextListener);
    ensureSelectionListenerRemoved();
  }

  /**
   * Ends any active run and informs all listeners. If there is none, nothing happens.
   * 
   * @param change the change that triggered ending the active run
   */
  private void endIfStarted(Change change) {
    if (hasRun()) {
      ensureSelectionListenerRemoved();
      if (DEBUG) {
        System.err.println("-End run"); //$NON-NLS-1$
      }
      fireRunEnded(fRun, change.getType());
      fRun = null;
    }
  }

  /**
   * Adds the selection listener to the text widget underlying the viewer, if not already done.
   */
  private void ensureSelectionListenerAdded() {
    if (fSelectionListener == null) {
      fSelectionListener = new SelectionListener();
      StyledText textWidget = fViewer.getTextWidget();
      textWidget.addFocusListener(fSelectionListener);
      textWidget.addKeyListener(fSelectionListener);
      textWidget.addMouseListener(fSelectionListener);
    }
  }

  /**
   * If there is a selection listener, it is removed from the text widget underlying the viewer.
   */
  private void ensureSelectionListenerRemoved() {
    if (fSelectionListener != null) {
      StyledText textWidget = fViewer.getTextWidget();
      textWidget.removeFocusListener(fSelectionListener);
      textWidget.removeKeyListener(fSelectionListener);
      textWidget.removeMouseListener(fSelectionListener);
      fSelectionListener = null;
    }
  }

  /**
   * Informs all listeners about a newly started <code>TypingRun</code>.
   * 
   * @param run the new run
   */
  private void fireRunBegun(TypingRun run) {
    List<ITypingRunListener> listeners = new ArrayList<ITypingRunListener>(fListeners);
    for (Iterator<ITypingRunListener> it = listeners.iterator(); it.hasNext();) {
      ITypingRunListener listener = it.next();
      listener.typingRunStarted(fRun);
    }
  }

  /**
   * Informs all listeners about an ended <code>TypingRun</code>.
   * 
   * @param run the previously active run
   * @param reason the type of change that caused the run to be ended
   */
  private void fireRunEnded(TypingRun run, ChangeType reason) {
    List<ITypingRunListener> listeners = new ArrayList<ITypingRunListener>(fListeners);
    for (Iterator<ITypingRunListener> it = listeners.iterator(); it.hasNext();) {
      ITypingRunListener listener = it.next();
      listener.typingRunEnded(fRun, reason);
    }
  }

  /**
   * State machine. Changes state given the current state and the incoming change.
   * 
   * @param change the incoming change
   */
  private void handleChange(Change change) {
    if (change.getType() == TypingRun.NO_CHANGE) {
      return;
    }

    if (DEBUG) {
      System.err.println("Last change: " + fLastChange); //$NON-NLS-1$
    }

    if (!change.canFollow(fLastChange)) {
      endIfStarted(change);
    }
    fLastChange = change;
    if (change.isModification()) {
      startOrContinue();
    }

    if (DEBUG) {
      System.err.println("New change: " + change); //$NON-NLS-1$
    }
  }

  /**
   * Returns <code>true</code> if there is an active run, <code>false</code> otherwise.
   * 
   * @return <code>true</code> if there is an active run, <code>false</code> otherwise
   */
  private boolean hasRun() {
    return fRun != null;
  }

  /**
   * Starts a new run if there is none and informs all listeners. If there already is a run, nothing
   * happens.
   */
  private void startOrContinue() {
    if (!hasRun()) {
      if (DEBUG) {
        System.err.println("+Start run"); //$NON-NLS-1$
      }
      fRun = new TypingRun(fLastChange.getType());
      ensureSelectionListenerAdded();
      fireRunBegun(fRun);
    }
  }
}
